Elegant Exception Handling

Eyal Trabelsi

  • thanks for coming out, I hope you enjoyed all the amazing talks by now.
  • today I am going to talk to you about Elegent Exception Handling.

About Me 🙈

  • Software Engineer at Salesforce 👷
  • Big passion for python, data and performance optimisations 🐍🤖

Restaurant Recommendation 🍔

  • Get user info 👨👩
  • Retrieve relevant restaurants 🍕🍗🍩🍔
  • Pick the best ones 🍗🍔

In [74]:
! pip install typeguard rollbar returns tenacity > /dev/null 2>&1

In [47]:
import contextlib
import json
import logging
import pathlib
import os
from typing import Union

import requests
from typeguard import typechecked

In [48]:
# Naive code snippets

def get_relevant_restaurants(user):
    base_url = "https://en.wikipedia.org/wiki"
    return requests.get(f"{base_url}/{user}").content

def get_user(path):
    with open(path, 'r') as json_file:
        return json.load(json_file)["user"]

def pick_best_restaurants(restaurants):
    pass

In [49]:
def get_restaurant_recommendation(path):
    user = get_user(path)
    candidates = get_relevant_restaurants(user)
    return pick_best_restaurants(candidates)
    
get_restaurant_recommendation("MY_AMAZING_FILE.json")

We Can Proud of Ourselves 💃

  • Implemented restaurant recommendation 💪
  • Clean code 💄

Why Exception Handling? 🤨

  • Hardware can fail 🌲
  • Software often fail 🚪

Unexceptable 😡

Lesson 1: We want to build a fault tolerant system.

Types of errors

  • error that can be detected at compile time
  • errors that can be deteled at run time
  • errors that can be infered
  • reproducieable erros
  • non reproduceable errors

We want our code to be safe 👷

Exception Handling to the Rescue 👨‍🚒

  • Detect errors 🕵
  • Do something about them 🔒

Naive Approach 👶

  • Log all exceptions 📝
  • Ignore all exceptions 🙈

In [175]:
def get_restaurant_recommendation(path):
    try:
        user = get_user(path)
        candidates = get_relevant_restaurants(user)
        pick_best_restaurants(candidates)
    except BaseException:
        logging.error("VERY UNINFORMATIVE INFORMATION")
        raise BaseException("VERY UNINFORMATIVE INFORMATION")

Are we done ?! 🍰

  • Code is very clean 🧹
  • Code seems safe 🔒

The lurking problems 🐲

  • Unintentional exceptions being caught 😧
  • KeyboardInterrupt as we want the user to be able to kill the program.
  • SynatxError as we want our code to be valid.
  • Exceptions are not distinguishable 😵
  • the invoker of this function can't really destinguise between the diffrent types of errors and allow to recover from certain expected issues.
  • For example, if we have flaky internet i would like to retry, but if a file is actually missing I dont.
  • Not safe 🔓
  • generaly it’s better for a program to fail fast and crash than to silence the error and continue running the program.

  • The bugs that inevitably happen later on will be harder to debug since they are far removed from the original cause.

  • Just because programmers often ignore error messages doesn’t mean the program should stop emitting them.

  • Unfortunately very common 😱

Types of exception handling

  • EAFP (it’s easier to ask for forgiveness than permission)
  • LBYL (Look before you leap)
  • Each has its own pros and cons (whether the thread-safty or readability)
  • but both are legitimate in python as oppose to other languages.

Take 2: Exception Handling 🎬

  • Catch relevant exceptions only ☝
  • Recover when possible 🔧

In [176]:
def get_restaurant_recommendation(path):
    try:
        user = get_config(path)["user"]
    except FileNotFoundException:
        logging.error("VERY UNINFORMATIVE INFORMATION")
        raise
    except JSONDecodeError:
        logging.error("VERY UNINFORMATIVE INFORMATION")
        raise
    candidates = get_relevant_restaurants(user)
    pick_best_restaurants(candidates)
  • When a file does not exists or when the file is not a valid json we raise FileNotFoundException and JSONDecodeError and log it away

  • We will reraise the same exact exception that occured instead raising a generic exception and allow the invoker to handle them diffrently.

  • Altough this code is far from pretty is much safer, we added deafault patiserie and the invoker of this function can destinguise between the diffrent types of errors and handle them in a diffrent manner if needed.

Lesson 2: Catch relevant exceptions only.

Our code is bad 😭

  • Dominated by exception handling
  • Business logic is not clear

Lesson 3: Error handling should not obscures business logic

  • Error handling is important, but we should strives to make our job easier.
  • as the zen of python state "If the implementation is hard to explain, it's a bad idea."

Take 3: Exception Handling 🎬

A Bit of Mackup 💄

  • Sharing exception blocks.
  • Use else clause.
  • Use dictionary built-in method

This


In [172]:
def get_restaurant_recommendation(path):
    try:
        user = get_user(path)
    except (FileNotFoundException, JSONDecodeError):
        logging.error("VERY UNINFORMATIVE INFORMATION")
        raise
    else:
        candidates = get_relevant_restaurants(user)
        pick_best_restaurants(candidates)

Becomes


In [171]:
def get_restaurant_recommendation(path):
    try:
        user = get_user(path)
    except (FileNotFoundException, JSONDecodeError):
        logging.error("VERY UNINFORMATIVE INFORMATION")
        raise
    else:
        candidates = get_relevant_restaurants(user)
        pick_best_restaurants(candidates)
  • First since we handle both FileNotFoundException and JSONDecodeError in the same manner they can "share the except block" as except clause may name multiple exceptions as a parenthesized tuple.
  • Secondly we can use else clause which occur when the try block executed and did not raise an exception.

  • Thirdly, we use dictionary builtin function get which allow us to define default values.

Lesson 4: Frequent flows probably have clean existing solution.

Suppressing Exceptions 🤫

  • There is another common flow for exception handling
  • i want to cover which is suppressing exceptions using suppress
  • supported from python>=3.5

In [55]:
def run_unstopable_animation():
    pass

This


In [56]:
try:
    os.remove('somefile.pyc')
except FileNotFoundError:
    pass

In [57]:
try:
    run_unstopable_animation()
except KeyboardInterrupt:
    pass

Becomes


In [58]:
from contextlib import suppress

with suppress(FileNotFoundError):
    os.remove('somefile.pyc')

In [59]:
from contextlib import suppress

with suppress(KeyboardInterrupt):
    run_unstopable_animation()

Reasons for errors

  • The obious one is that something exceptional happened.
  • As a control flow mechanism.
  • Can be triggered due to a bug in our code.

Still dominated by exception handling code😟

Take 3: Exception Handling 🎬

  • Separate business logic from exception handling code ✂
  • Handled exceptions in other layer 📚

This


In [169]:
def get_user(path):
    with open(path, 'r') as json_file:
        return json.load(json_file)\
                   .get("user", "default_user")
    
def get_restaurant_recommendation(path):
    try:
        user = get_user(path)
    except (FileNotFoundException, JSONDecodeError):
        logging.error("VERY UNINFORMATIVE INFORMATION")
        raise
    else:
        candidates = get_relevant_restaurants(user)
        pick_best_restaurants(candidates)

Becomes


In [170]:
def get_user(path):
    with open(path, 'r') as json_file:
        try:
            user = json.load(json_file)\
                       .get("user", "default_user")
        except (FileNotFoundException, JSONDecodeError):
            logging.error("VERY UNINFORMATIVE INFORMATION")
            raise
        else:
            return user

def get_restaurant_recommendation(path):
    user = get_user(path)                
    candidates = get_relevant_restaurants(user)
    pick_best_restaurants(candidates)

Lesson 5: Push the exception handling down/up in the abstraction level

Are we completly safe now? 👷

Silent Errors 🔇

  • Does not crash code 🤯
  • Delivers incorrect results 😠
  • Makes matter worse 🤬

Contract 📜

  • Output/Input types
  • Output/Input values
  • Postconditions/Preconditions
  • Side-effects/Invariants

Vanilla Exceptions 🍧

  • All the validations are supported ✅
  • Happens in runtime ✅ but not in compilation time ❌
  • Not clean❌

Why not assertions ? ❌

  • Raises the wrong exception type 😮
  • Can be compiled away 😥

Type Hints 🔍

  • Support validating input/output types ✅
  • Doesn't support other validation ❌
  • Support both Runtime ✅ / Compile time ✅
  • Clean and elegant ✅

mypy for compile time


In [144]:
def get_user(path: Union[str, pathlib.PurePath]) -> str:
    with open(path, 'r') as json_file:
        try:
            data = json.load(json_file)
        except (FileNotFoundException, JSONDecodeError):
            logging.error("VERY INFORMATIVE INFORMATION")
            raise
        else:
            return data.get("user","default_user")

typeguard for runtime


In [145]:
@typechecked
def get_user(path: Union[str, pathlib.PurePath]) -> str:
    with open(path, 'r') as json_file:
        try:
            data = json.load(json_file)
        except (FileNotFoundException, JSONDecodeError):
            logging.error("VERY INFORMATIVE INFORMATION")
            raise
        else:
            return data.get("user","default_user")

Contract Testing Libraries 📜

  • All the validations are supported ✅
  • Happens in runtime ✅ but not in compilation time ❌
  • Clean and elegant ✅
  • No mature/maintained option ❌

Lesson 6: Protect your code from silent errors.

There are still problems lurking 🐉


In [126]:
def get_relevant_restaurants(user):
    base_url = "cool_restaurants.com"
    resp = requests.get(f"{base_url}/{user}")
    resp.raise_for_status()
    return resp.content

Unstable environment 🤪

  • Your network might be down 😑
  • The server might be down 😣
  • The server might be too busy and you will face a timeout 😭

In [127]:
def get_relevant_restaurants(user):
    base_url = "cool_restaurants.com"
    
    allowed_retries = 5
    for i in range(allowed_retries):
        try:
            resp = requests.get(f"{base_url}/{user}")
            resp.raise_for_status()
        except (requests.ConnectionError):
            if i == allowed_retries:
                raise
        else:
            return resp.content

There must be better way 😇

  • Decorators extend our function capabilities beyond its core intent🎊
  • Context Managers wrap around enter and exit logic over a given resource 🌉
  • Common usecases already implemented 💪

In [128]:
from functools import wraps
def retry(exceptions, allowed_retries=5):
    def callable(func):
        @wraps(func)
        def wrapped(*args, **kwargs):
            for i in range(allowed_retries):
                try:
                    res = func()
                except exceptions:
                    continue
                else:
                    return res       
        return wrapped
    return callable

In [129]:
@retry(exceptions=requests.ConnectionError)
def get_relevant_restaurants(country):
    base_url = "cool_restaurants.com"
    resp = requests.get(f"{base_url}/{user}")
    resp.raise_for_status()
    return resp.content

In [147]:
import tenacity

@tenacity.retry(retry=tenacity.retry_if_exception_type(ConnectionError))
def get_relevant_restaurants(user):
    base_url = "cool_restaurants.com"
    resp = requests.get(f"{base_url}/{user}")
    resp.raise_for_status()
    return resp.content

Useful usecases 🧠

  • important note retry can be handled in the request itself by writing an adapter, but for the example sake i wont use it.

Lesson 7: Use decorators and context managers

Python hooks 🎣

  • Python has builtin hooks for various events
  • Doesn't require modifying existing code.

sys's excepthook example! 🎣

  • Uncaught exception print traceback to STDERR before closing
  • Unacceptable in production environment
  • Graceful exit by notify incident system

In [89]:
import sys
import rollbar

rollbar.init("Super Secret Token")

def rollbar_except_hook(exc_type, exc_value, traceback):
    rollbar.report_exc_info((exc_type, exc_value, traceback))
    sys.__excepthook__(exc_type, exc_value, traceback)
    
sys.excepthook = rollbar_except_hook


WARNING:rollbar:Rollbar already initialized. Ignoring re-init.

Useful usecases 🧠

  • We can format the exceptions diffrently, to provide more/less information.
  • We can redirect Exceptions to an incident system like rollbar or pager-duty.
  • Since threading/multiprocessing have their own unhandled exception machinery. that is a bit customized so no unhandled exception exists at the top level. we might want to overide it to support KeyboardInterupt for example.
  • Search Stackoverflow for the exception that was being raise

Lesson 8: Python has some useful builtin hooks

Exceptions Components 📑

  • Exception message 💬
  • Exception type 🍅🍇🍆
  • Exception cause 🤯

Exception Types 🍅🍇🍆

  • Helps distinguish between different exceptions
  • Helps emphasis our intent
  • Builtin and Custom exceptions

Builtin Exception Types 🍅🍇🍆

  • Dozens of built-in exceptions
  • Well documented, we can use some stackoverflow magic
  • Should use builtin exceptions

Custom Exception Types 🍅🍇🍆

  • Emphasis our intent
  • Distinguish between different exceptions.

Lets say we have ValueError and we want to recover in diffrent way between TooBig/TooSmall.

  • Group different exceptions.
  • Wrapping third party apis.
  • when we wrap third party api we minimize our dependecy on it. for example uppon recovery shouldn't have to import exceptions from your dependecies for example requests.exceptions
  • Also the users that use your library does not need/want to know about the implementation details.

Wrapping third party example 👀

  • get_restaurant_recommendation can raise requests.ConnectionError
  • Recovering in get_restaurant_recommendation
  • Require import requests for exceptions

Lesson 9: Pick the right exception types and messages.

Exception cause 🤯

  • cause indicates the reason of the error
  • We can overide the cause to replace exception raised

Python default behavior

  • When a modern Python developer catches an exception and raises a new one to replace it, they can enjoy seeing the complete information for both tracebacks.
  • This is very helpful for debugging, and is a win for everybody.

In [159]:
try:
    1/0
except ZeroDivisionError:
    raise


---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
<ipython-input-159-21367911f8dd> in <module>
      1 try:
----> 2     1/0
      3 except ZeroDivisionError:
      4     raise

ZeroDivisionError: division by zero

Replace exception type with both traces


In [161]:
try:
    1/0
except ZeroDivisionError as e:
    raise Exception from e


---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
<ipython-input-161-54b6182ebe0c> in <module>
      1 try:
----> 2     1/0
      3 except ZeroDivisionError as e:

ZeroDivisionError: division by zero

The above exception was the direct cause of the following exception:

Exception                                 Traceback (most recent call last)
<ipython-input-161-54b6182ebe0c> in <module>
      2     1/0
      3 except ZeroDivisionError as e:
----> 4     raise Exception from e

Exception: 

Replace exception type with only one trace


In [162]:
try:
    1/0
except ZeroDivisionError as e:
    raise Exception from None


---------------------------------------------------------------------------
Exception                                 Traceback (most recent call last)
<ipython-input-162-1afcf5420562> in <module>
      2     1/0
      3 except ZeroDivisionError as e:
----> 4     raise Exception from None

Exception: 

Lesson 10: Replace exceptions using cause.

Still not perfect 💯

  • Hard to tell what exceptions can be thrown
  • Hard to tell where exceptions will be handled
  • No static analysis

Functional Exception Handling for the rescue 🚔

  • Use success/failure container values
  • functions are typed, and safe
  • Railway oriented programming


In [ ]:
def pick_best_restaurants(user: str, candidates: List[str]) -> List[str]:    
    validate_user(user)
    best_candicates = heapq.nlargest(5, valid_candidates)
    update_df(user, best_candicates)
    send_email()

In [174]:
def get_user(path):
    with open(path, 'r') as json_file:
        try:
            user = json.load(json_file)\
                       .get("user", "default_user")
        except (FileNotFoundException, JSONDecodeError):
            logging.error("VERY UNINFORMATIVE INFORMATION")
            raise
        else:            
            try:
                send_email(user)
            else :
                
            return user


---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-174-c71ba4f6a8fa> in <module>
      1 import heapq
      2 
----> 3 def pick_best_restaurants(user: str, candidates: List[str]) -> List[str]:
      4     try:
      5         validate_user(user)

NameError: name 'List' is not defined

Lesson 11: For complicated usecases consider the functional way.

Lesson 12: Split nested try catch blocks to separated try blocks

  • Avoid nested exception handling
  • Split to multiple exception blocks
  • zen of python state "Flat is better than nested".

Not all programs made equal 👯

  • Extremely reliable ✈ ✨
  • Highly reliable 🚘
  • Reliable 💳
  • Dodgy 📱
  • Crap 💩

Lesson 13: We want to build a fault tolerant to a certain degree

Sensative information 🕵

  • Messages will be spread far and wide 🇫🇷🇺🇸🇫🇷

through logging, reporting, and monitoring software.

  • Personal data privacy 🕵

In a world where regulation around personal data is constantly getting stricter,

  • Never reveal your weaknesses, bad actors are everywhere 👺
  • You can never be too careful 🤓

In [90]:
def login(user):
    raise CommonPasswordException(f"password: {password} is too common")

Lesson 14: Don’t use sensitive information in exception messages.

Lessons: 👨‍🏫👩‍🏫

  • Lesson 1: We want to build a fault tolerant system.
  • Lesson 2: Catch relevant exceptions only.
  • Lesson 3: Error handling should not obscures the logic.
  • Lesson 4: Frequent flows probably have clean existing solution.
  • Lesson 5: Push the exception handling down/up in the abstraction level
  • Lesson 6: Protect your code from silent errors.
  • Lesson 7: Use decorators and context managers
  • Lesson 8: Python has some useful builtin hooks.
  • Lesson 9: Pick the right exception types and messages.
  • Lesson 10: Replace exceptions using cause.
  • Lesson 11: For complicated usecases consider the functional way.
  • Lesson 12: Split nested try catch blocks to separated try blocks
  • Lesson 13: We want to build a fault tolerant to a certain degree.
  • Lesson 14: Don’t use sensitive information in exception messages.

Additional Resources 📚

Recoverability 🩹

  • How do i recover

    • how can you make sure all bad state is cleared away to retry
  • what is recoverable:

    • network flakiness
    • database out of connection
    • disk unavailable
    • recoverable database out of connections

Concurrent/Parallal Exception handling 🎸🎺🎻🎷

  • Multi-threading/processing 1, 2
  • Async 1,2, 3

Error codes 👾

When

  • WITHIN a program one should always use exceptions.
  • Any time the error must leave the program you are left with error error codes as exceptions can't propagate beyond a program.
  • If, however, I'm writing a piece of code which I must know the behaviour of in every possible situation, then I want error codes.
  • It's tedious and hard to write code that reacts appropriately to every situatio, but that's because writing error-free code is tedious and hard, not because you're passing error code

Pros

  • That being said, errors, whether in code form or simple error response, are a bit like getting a shot — unpleasant, but incredibly useful. Error codes are probably the most useful diagnostic element in the API space, and this is surprising, given how little attention we often pay them.

  • In general, the goal with error responses is to create a source of information to not only inform the user of a problem, but of the solution to that problem as well. Simply stating a problem does nothing to fix it – and the same is true of API failures.

Release it 📪

  • you can always reboot the world by restarding every single server layer by layer thats
  • almost always effective but takes long time
  • its like a doctor diagnosing desease, theyou could treat a patient,
  • counter integration point with circuit breaker and decoupling middleware
  • a cascading failure happens after something else already gone wrong. circuit breaker protect your system by avoiding calls out to the troubled integration point. using timeout ensure that you can come back from a call out to the troubled one